package pl.polidea.constrainedbuffer;

import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;

/**
 * The Class ConstrainedBuffer.
 * 
 * @param <Type>
 *            the generic type
 */
public class ConstrainedBuffer<Type extends Serializable> {

    /** The storage file. */
    private File storageFile;

    /** The size. */
    private int size = 0;
    /** The entries. */
    private final List<BufferEntry> entries = new ArrayList<BufferEntry>();

    public ConstrainedBuffer(final File storage) throws IOException, ClassNotFoundException {
        if (storage == null) {
            throw new IllegalArgumentException("storage cannot be null");
        }
        if (!storage.exists()) {
            throw new IOException("storage must exist");
        }
        if (storage.isDirectory()) {
            createStorage(storage);
        } else {
            setStorage(storage);
        }
    }

    /**
     * Sets the storage.
     * 
     * @param storageFile
     *            the new storage
     * @throws IOException
     *             Signals that an I/O exception has occurred.
     * @throws ClassNotFoundException
     */
    private synchronized void setStorage(final File storageFile) throws IOException, ClassNotFoundException {
        this.storageFile = storageFile;
        readEntries();
    }

    /**
     * Read entries.
     * 
     * @throws IOException
     *             Signals that an I/O exception has occurred.
     * @throws ClassNotFoundException
     */
    private void readEntries() throws IOException, ClassNotFoundException {
        final ObjectInputStream stream = new ObjectInputStream(new FileInputStream(storageFile));

        try {
            // Terminated when EOFException thrown
            while (true) {
                final BufferEntry entry = (BufferEntry) stream.readObject();

                if (entry.file != null) {
                    size += entry.size;
                }
                entries.add(entry);
            }
        } catch (final EOFException e) {
            stream.close();
        }
    }

    /**
     * Creates the storage.
     * 
     * @param storageDirectory
     *            the storage directory
     * @throws IOException
     *             Signals that an I/O exception has occurred.
     */
    public synchronized void createStorage(final File storageDirectory) throws IOException {
        this.storageFile = File.createTempFile("constrainedbuffer", null, storageDirectory);
    }

    /**
     * Delete storage.
     * 
     * @throws IOException
     *             Signals that an I/O exception has occurred.
     */
    public synchronized void deleteStorage() throws IOException {
        for (int i = 0; i < entries.size(); i++) {
            new File(entries.get(i).file).delete();
        }
        entries.clear();
        if (storageFile != null && storageFile.exists()) {
            storageFile.delete();
        }
    }

    /**
     * Reset storage.
     * 
     * @throws IOException
     *             Signals that an I/O exception has occurred.
     */
    public synchronized void resetStorage() throws IOException {
        deleteStorage();
        createStorage(storageFile.getParentFile());
    }

    /**
     * The listener interface for receiving timeConstraintViolated events. The
     * class that is interested in processing a timeConstraintViolated event
     * implements this interface, and the object created with that class is
     * registered with a component using the component's
     * <code>addTimeConstraintViolatedListener<code> method. When
     * the timeConstraintViolated event occurs, that object's appropriate
     * method is invoked.
     * 
     * @see TimeConstraintViolatedEvent
     */
    public interface TimeConstraintViolatedListener {

        /**
         * On time constraint violated.
         */
        void onTimeConstraintViolated();
    }

    /**
     * The listener interface for receiving numberConstraintViolated events. The
     * class that is interested in processing a numberConstraintViolated event
     * implements this interface, and the object created with that class is
     * registered with a component using the component's
     * <code>addNumberConstraintViolatedListener<code> method. When
     * the numberConstraintViolated event occurs, that object's appropriate
     * method is invoked.
     * 
     * @see NumberConstraintViolatedEvent
     */
    public interface CountConstraintViolatedListener {

        /**
         * On number constraint violated.
         */
        void onCountConstraintViolated();
    }

    /**
     * The listener interface for receiving sizeConstraintViolated events. The
     * class that is interested in processing a sizeConstraintViolated event
     * implements this interface, and the object created with that class is
     * registered with a component using the component's
     * <code>addSizeConstraintViolatedListener<code> method. When
     * the sizeConstraintViolated event occurs, that object's appropriate
     * method is invoked.
     * 
     * @see SizeConstraintViolatedEvent
     */
    public interface SizeConstraintViolatedListener {

        /**
         * On size constraint violated.
         */
        void onSizeConstraintViolated();
    }

    /** The time listener. */
    private TimeConstraintViolatedListener timeListener = new TimeConstraintViolatedListener() {

        @Override
        public void onTimeConstraintViolated() {
            // nop
        }
    };

    /** The number listener. */
    private CountConstraintViolatedListener numberListener = new CountConstraintViolatedListener() {

        @Override
        public void onCountConstraintViolated() {
            // nop
        }
    };

    /** The size listener. */
    private SizeConstraintViolatedListener sizeListener = new SizeConstraintViolatedListener() {

        @Override
        public void onSizeConstraintViolated() {
            // nop
        }
    };

    /** The Constant DEFAULT_TIME_CONSTRAINT. */
    private static final long DEFAULT_TIME_CONSTRAINT = 1000 * 60 * 60 * 2; // 2h

    /** The Constant DEFAULT_NUMBER_CONSTRAINT. */
    private static final int DEFAULT_NUMBER_CONSTRAINT = 200;

    /** The Constant DEFAULT_SIZE_CONSTRAINT. */
    private static final long DEFAULT_SIZE_CONSTRAINT = 1024 * 1024; // 1MB

    /** The time constraint. */
    private long timeConstraint = DEFAULT_TIME_CONSTRAINT;

    /** The number constraint. */
    private int numberConstraint = DEFAULT_NUMBER_CONSTRAINT;

    /** The size constraint. */
    private long sizeConstraint = DEFAULT_SIZE_CONSTRAINT;

    /**
     * Gets the time constraint.
     * 
     * @return the time constraint
     */
    public long getTimeConstraint() {
        return timeConstraint;
    }

    /**
     * Sets the time constraint.
     * 
     * @param timeConstraint
     *            the new time constraint
     */
    public void setTimeConstraint(final long timeConstraint) {
        final long currentTime = getTime();
        if (!entries.isEmpty() && currentTime - entries.get(0).time >= timeConstraint) {
            timeListener.onTimeConstraintViolated();
            while (!entries.isEmpty() && currentTime - entries.get(0).time >= timeConstraint) {
                removeFront();
            }
        }
        this.timeConstraint = timeConstraint;
    }

    /**
     * Gets the number constraint.
     * 
     * @return the number constraint
     */
    public int getNumberConstraint() {
        return numberConstraint;
    }

    /**
     * Sets the number constraint.
     * 
     * @param numberConstraint
     *            the new number constraint
     */
    public void setCardinalityConstraint(final int numberConstraint) {
        if (entries.size() > numberConstraint) {
            numberListener.onCountConstraintViolated();
            while (entries.size() > numberConstraint) {
                removeFront();
            }
        }
        this.numberConstraint = numberConstraint;
    }

    /**
     * Gets the size constraint.
     * 
     * @return the size constraint
     */
    public long getSizeConstraint() {
        return sizeConstraint;
    }

    /**
     * Sets the size constraint.
     * 
     * @param sizeConstraint
     *            the new size constraint
     */
    public void setSizeConstraint(final long sizeConstraint) {
        if (size > sizeConstraint) {
            sizeListener.onSizeConstraintViolated();
            while (!entries.isEmpty() && size > sizeConstraint) {
                removeFront();
            }
        }
        this.sizeConstraint = sizeConstraint;
    }

    /**
     * Adds the.
     * 
     * @param item
     *            the item
     * @throws IOException
     *             Signals that an I/O exception has occurred.
     */
    public synchronized void add(final Type item) throws IOException {
        if (item == null) {
            return;
        }
        final ByteArrayOutputStream byteArray = new ByteArrayOutputStream();
        final ObjectOutputStream stream = new ObjectOutputStream(byteArray);
        stream.writeObject(item);
        stream.close();
        final byte[] bytes = byteArray.toByteArray();
        final int entrySize = bytes.length;

        final File parentFile = storageFile.getParentFile();
        final File createTempFile = File.createTempFile("bufferentry", null, parentFile);
        final String entryFile = createTempFile.getPath();
        final BufferEntry entry = new BufferEntry(entryFile, entrySize, getTime());

        if (size + entrySize > sizeConstraint) {
            sizeListener.onSizeConstraintViolated();
            while (!entries.isEmpty() && size + entrySize > sizeConstraint) {
                removeFront();
            }
        }
        if (entries.size() == numberConstraint) {
            numberListener.onCountConstraintViolated();
            removeFront();
        }
        if (!entries.isEmpty() && entry.time - entries.get(0).time > timeConstraint) {
            timeListener.onTimeConstraintViolated();
            while (!entries.isEmpty() && entry.time - entries.get(0).time > timeConstraint) {
                removeFront();
            }
        }

        entries.add(entry);
        size += entrySize;
        final FileOutputStream fileStream = new FileOutputStream(entryFile);
        fileStream.write(bytes);
        fileStream.close();

        saveEntries();
    }

    /**
     * Removes one item from index 0.
     */
    private void removeFront() {
        final BufferEntry entry = entries.remove(0);
        size -= entry.size;
        new File(entry.file).delete();
    }

    /**
     * Gets the specified item.
     * 
     * @param index
     *            the index
     * @return the type
     * @throws IOException
     *             Signals that an I/O exception has occurred.
     * @throws ClassNotFoundException
     *             the class not found exception
     */
    @SuppressWarnings("unchecked")
    public synchronized Type get(final int index) throws IOException, ClassNotFoundException {
        final BufferEntry entry = entries.get(index);
        final ObjectInputStream stream = new ObjectInputStream(new FileInputStream(entry.file));
        final Type object = (Type) stream.readObject();
        stream.close();
        return object;
    }

    /**
     * gets number of items.
     * 
     * @return the int
     */
    public synchronized int size() {
        return entries.size();
    }

    /**
     * Save entries.
     * 
     * @throws IOException
     *             Signals that an I/O exception has occurred.
     */
    private void saveEntries() throws IOException {
        final ObjectOutputStream stream = new ObjectOutputStream(new FileOutputStream(storageFile));
        for (int i = 0; i < entries.size(); i++) {
            stream.writeObject(entries.get(i));
        }
        stream.close();
    }

    /**
     * Sets the time listener.
     * 
     * @param timeListener
     *            the new time listener
     */
    public void setTimeConstraintViolatedListener(final TimeConstraintViolatedListener timeListener) {
        this.timeListener = timeListener;
    }

    /**
     * Sets the number listener.
     * 
     * @param numberListener
     *            the new number listener
     */
    public void setCountConstraintViolatedListener(final CountConstraintViolatedListener numberListener) {
        this.numberListener = numberListener;
    }

    /**
     * Sets the size listener.
     * 
     * @param sizeListener
     *            the new size listener
     */
    public void setSizeConstraintViolatedListener(final SizeConstraintViolatedListener sizeListener) {
        this.sizeListener = sizeListener;
    }

    /**
     * Gets the storage file.
     * 
     * @return the storage file
     */
    public File getStorageFile() {
        return storageFile;
    }

    interface TimeProvider {
        long getTime();
    }

    TimeProvider timeProvider = new TimeProvider() {

        @Override
        public long getTime() {
            return System.currentTimeMillis();
        }
    };

    long getTime() {
        return timeProvider.getTime();
    }

    void setTimeProvider(final TimeProvider timeProvider) {
        this.timeProvider = timeProvider;
    }

    public long sizeInBytes() {
        return size;
    }
}
